package com.ar4j.handler;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Map;

import javax.sql.DataSource;

import org.antlr.stringtemplate.StringTemplate;
import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.apache.commons.lang.builder.ToStringBuilder;
import org.apache.commons.lang.builder.ToStringStyle;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

import net.sf.cglib.proxy.MethodProxy;

import com.ar4j.ActiveRecord;
import com.ar4j.ActiveRecordContext;
import com.ar4j.ActiveRecordMetadata;
import com.ar4j.sql.ActiveRecordSqlParameterSource;
import com.ar4j.sql.SqlUtils;
import com.ar4j.util.ClassUtils;
import com.ar4j.util.CollectionUtils;
import com.ar4j.util.ObjectCache;

/**
 * A handler for active record create/update (save) operations
 */
public class SaveMethodHandler implements IMethodHandler {
  private StringTemplate updateTemplate;
  private ObjectCache<InsertCacheKey, SimpleJdbcInsert> insertCache;
  
  @Override
  public void init(ActiveRecordMetadata metadata, Method method) {
    
    // initialize the insert cache
    String cacheName = this.getClass().getName() + "(" + metadata.getBaseClass().getName() + ").insert";
    insertCache = new ObjectCache<InsertCacheKey, SimpleJdbcInsert>(cacheName, 128, 0);
    
    // compose the update query
    StringBuilder sql = new StringBuilder();
    sql.append("UPDATE $qualifiedTable$ ");
    sql.append(" SET ");
    
    boolean first = true;
    for(Map.Entry<String, String> entry : metadata.getPropertyColumnNameMap().entrySet()) {
      if(metadata.getIdentifierPropertyName().equals(entry.getKey())) {
        continue;
      }
      
      if(!first) {
        sql.append(", ");
      } else {
        first = false;
      }
     
      sql.append(entry.getValue());
      sql.append(" = :");
      sql.append(entry.getValue());
    }
    
    SqlUtils.appendWhereClause(sql, CollectionUtils.getSingleValueCollection(metadata.getIdentifierPropertyName()), 
        new ArrayList<String>(), null, metadata.getPropertyColumnNameMap());
    
    updateTemplate = new StringTemplate(sql.toString());
  }
  
  /**
   * Creates a new record if one did not exist, or updates a record with the data contained in the 
   * active record.
   */  
  @Override
  public Object intercept(ActiveRecordMetadata metadata, ActiveRecordContext context, Object target, Method method, Object[] args, MethodProxy proxy) 
    throws Throwable {
    
    // make sure we have a template first
    if(context.getSimpleTemplate() == null || context.getDataSource() == null) {
      throw new IllegalStateException("Cannot execute find on an active record that does not have a datasource set");
    }
    
    ActiveRecord<?> record = (ActiveRecord<?>) target;
    if(record.isNewRecord()) {
      Number key = create(record, metadata, context);
      Object id = ClassUtils.coerceValueToType(key, record.getIdentifierPropertyType());
      record.setProperty(record.getIdentifierPropertyName(), id);
    } else {
      update(record, metadata, context);
    }
    
    record.reload();
    return record;
  }
  
  /**
   * Perform a SQL insert with the active record data
   */ 
  private Number create(ActiveRecord<?> record, ActiveRecordMetadata metadata, ActiveRecordContext context) {
    
    // create the insert if needed
    InsertCacheKey key = new InsertCacheKey(context.getDataSource(), context.getSchemaName());
    SimpleJdbcInsert insert = insertCache.get(key);
    if(insert == null) {
      insert = (SimpleJdbcInsert) new SimpleJdbcInsert(key.getDataSource())
        .withoutTableColumnMetaDataAccess()
        .withSchemaName(key.getSchemaName())  
        .withTableName(metadata.getTableName())
        .usingGeneratedKeyColumns(getAutogeneratedColumns(metadata))
        .usingColumns(getNonIdentifierColumns(metadata));
      
      insertCache.put(key, insert);
    }
    
    ActiveRecordSqlParameterSource parameters = new ActiveRecordSqlParameterSource(record, metadata);
    return insert.executeAndReturnKey(parameters);
  }
  
  /**
   * Perform a SQL update with the active record date, the identifier will never be updated.
   */ 
  private void update(ActiveRecord<?> record, ActiveRecordMetadata metadata, ActiveRecordContext context) {
    String sql = HandlerUtils.renderQuery(updateTemplate, metadata, context);
    ActiveRecordSqlParameterSource parameters = new ActiveRecordSqlParameterSource(record, metadata);
    context.getSimpleTemplate().update(sql, parameters);
  }
    
  
  /**
   * @return an array of column names that will be autogenerated by the backing store
   */ 
  private String[] getAutogeneratedColumns(ActiveRecordMetadata metadata) {
    String[] out = new String[metadata.getPropertiesAutogeneratedInBackingStore().size()];
    int i = 0;
    for(Iterator<String> it  = metadata.getPropertiesAutogeneratedInBackingStore().iterator(); it.hasNext(); i++) {
      out[i] = metadata.getPropertyColumnNameMap().get(it.next());
    }
    
    return out;
  }
  
  /**
   * @return an array of column names for all non identifier properties for the given active record
   * metadata
   */ 
  private String[] getNonIdentifierColumns(ActiveRecordMetadata metadata) {
    String[] columns = new String[metadata.getPropertyNames().size() - 1];
    int i = 0;
    for(Map.Entry<String, String> entry : metadata.getPropertyColumnNameMap().entrySet()) {
      if(metadata.getIdentifierPropertyName().equals(entry.getKey())) {
        continue;
      }
      
      columns[i] = entry.getValue();
      i++;
    }
    
    return columns;
  }
  
  @Override
  public String toString() {
    return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE);
  }
  
  /**
   * Composite object holding the key to the insert cache. Consists of a data source and a 
   * schema name (which could be null).
   */
  private static class InsertCacheKey {
    private DataSource dataSource;
    private String schemaName;
    private int hashCode;
    
    public InsertCacheKey(DataSource dataSource, String schemaName) {
      this.dataSource = dataSource;
      this.schemaName = schemaName;
      this.hashCode = HashCodeBuilder.reflectionHashCode(this);
    }

    public DataSource getDataSource() { return dataSource; }
    public String getSchemaName() { return schemaName; }

    @Override public boolean equals(Object obj) { return EqualsBuilder.reflectionEquals(this, obj); }
    @Override public int hashCode() { return hashCode; }
    @Override public String toString() { return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE); }
  }
  
}
